Skip to content

结构化大模型输出:output parser 还是 tool

我们已经调用大模型完成过很多功能了,但输出一直没做控制,都是自然语言的形式。而很多情况下,我们希望大模型按照我们的格式要求,返回一个 JSON——这就需要用到 output parser 的 API 了。

有同学说,这个不就是在 prompt 里描述下要什么格式,然后按照这种格式解析大模型返回的结果字符串么?没错,就是这种思路,只不过 output parser 对这个思路做了一下封装。

我们直接写代码来试一下:

bash
mkdir output-parser-test
cd output-parser-test
npm init -y
pnpm install @langchain/core @langchain/openai chalk dotenv zod

.env 配置和之前一样(OPENAI_API_KEY / OPENAI_BASE_URL / MODEL_NAME=qwen-plus)。

问题引入:直接要求 JSON 格式

创建 src/normal.mjs

js
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  temperature: 0,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

// 简单的问题,要求 JSON 格式返回
const question = "请介绍一下爱因斯坦的信息。请以 JSON 格式返回,包含以下字段:name(姓名)、birth_year(出生年份)、nationality(国籍)、famous_theory(著名理论)、major_achievements(主要成就)";

try {
  console.log("🤔 正在调用大模型...\n");
  const response = await model.invoke(question);
  console.log("✅ 收到响应:\n");
  console.log(response.content);
  // 解析 JSON
  const jsonResult = JSON.parse(response.content);
  console.log("\n📋 解析后的 JSON 对象:");
  console.log(jsonResult);
} catch (error) {
  console.error("❌ 错误:", error.message);
}

我们让大模型用 JSON 格式返回爱因斯坦的信息,然后把返回的 json 解析成对象。跑一下会发现:返回的内容带了额外的 markdown 语法,解析失败了——这是我们经常遇到的一个问题。

OutputParser:JsonOutputParser

创建 src/json-output-parser.mjs

js
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { JsonOutputParser } from '@langchain/core/output_parsers';

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  temperature: 0,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

const parser = new JsonOutputParser();

const question = `请介绍一下爱因斯坦的信息。请以 JSON 格式返回,包含以下字段:name(姓名)、birth_year(出生年份)、nationality(国籍)、famous_theory(著名理论)、major_achievements(主要成就)
${parser.getFormatInstructions()}`;

try {
  console.log("🤔 正在调用大模型(使用 JsonOutputParser)...\n");
  const response = await model.invoke(question);
  console.log("📤 模型原始响应:\n");
  console.log(response.content);

  const result = await parser.parse(response.content);
  console.log("✅ JsonOutputParser 自动解析的结果:\n");
  console.log(result);
  console.log(`姓名: ${result.name}`);
  console.log(`出生年份: ${result.birth_year}`);
  console.log(`国籍: ${result.nationality}`);
  console.log(`著名理论: ${result.famous_theory}`);
  console.log(`主要成就:`, result.major_achievements);
} catch (error) {
  console.error("❌ 错误:", error.message);
}

JsonOutputParser,顾名思义,它就是用来解析 json 结果的。就像前面说的:在 prompt 里放一段格式的提示词,然后对返回的结果按照格式来 parse——分别对应 parser.getFormatInstructionsparser.parse 方法。

跑一下可以看到:虽然大模型返回的还是带了 markdown 语法,但是 JsonOutputParser 能够解析其中的 json,因为它做了这种常见情况的处理。

有的同学说,getFormatInstructions 好像没内容啊——确实,JsonOutputParser 比较简单,不需要提示词。

OutputParser:StructuredOutputParser

换一个 output parser,创建 src/structured-output-parser.mjs

js
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { StructuredOutputParser } from '@langchain/core/output_parsers';

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  temperature: 0,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

// 定义输出结构
const parser = StructuredOutputParser.fromNamesAndDescriptions({
  name: "姓名",
  birth_year: "出生年份",
  nationality: "国籍",
  major_achievements: "主要成就,用逗号分隔的字符串",
  famous_theory: "著名理论"
});

const question = `请介绍一下爱因斯坦的信息。
${parser.getFormatInstructions()}`;

try {
  console.log("🤔 正在调用大模型(使用 StructuredOutputParser)...\n");
  const response = await model.invoke(question);
  console.log("📤 模型原始响应:\n");
  console.log(response.content);

  const result = await parser.parse(response.content);
  console.log("\n✅ StructuredOutputParser 自动解析的结果:\n");
  console.log(result);
  console.log(`姓名: ${result.name}`);
  console.log(`出生年份: ${result.birth_year}`);
  console.log(`国籍: ${result.nationality}`);
  console.log(`著名理论: ${result.famous_theory}`);
  console.log(`主要成就: ${result.major_achievements}`);
} catch (error) {
  console.error("❌ 错误:", error.message);
}

这里我们用 StructuredOutputParser,它可以指定具体的 json 结构:用 fromNamesAndDescriptions 指定字段和描述。跑一下,解析出的对象依然正确,但现在 prompt 里多了一大段提示词——这就是 output parser 的原理:在 prompt 里加入格式描述,根据这个格式来解析响应

当然,就像我们之前用 zod 来描述 tool 的参数格式一样,StructuredOutputParser 也可以用 zod 来描述复杂的对象格式(fromZodSchema):

js
import { z } from 'zod';

// 使用 zod 定义复杂的输出结构
const scientistSchema = z.object({
  name: z.string().describe("科学家的全名"),
  birth_year: z.number().describe("出生年份"),
  death_year: z.number().optional().describe("去世年份,如果还在世则不填"),
  nationality: z.string().describe("国籍"),
  fields: z.array(z.string()).describe("研究领域列表"),
  awards: z.array(
    z.object({
      name: z.string().describe("奖项名称"),
      year: z.number().describe("获奖年份"),
      reason: z.string().optional().describe("获奖原因")
    })
  ).describe("获得的重要奖项列表"),
  major_achievements: z.array(z.string()).describe("主要成就列表"),
  famous_theories: z.array(
    z.object({
      name: z.string().describe("理论名称"),
      year: z.number().optional().describe("提出年份"),
      description: z.string().describe("理论简要描述")
    })
  ).describe("著名理论列表"),
  education: z.object({
    university: z.string().describe("主要毕业院校"),
    degree: z.string().describe("学位"),
    graduation_year: z.number().optional().describe("毕业年份")
  }).optional().describe("教育背景"),
  biography: z.string().describe("简短传记,100 字以内")
});

// 从 zod schema 创建 parser
const parser = StructuredOutputParser.fromZodSchema(scientistSchema);

const question = `请介绍一下居里夫人(Marie Curie)的详细信息,包括她的教育背景、研究领域、获得的奖项和著名理论。
${parser.getFormatInstructions()}`;

const response = await model.invoke(question);
const result = await parser.parse(response.content);
console.log(JSON.stringify(result, null, 2));

我们用一个 zod 描述了复杂的对象结构(嵌套字段、数组),然后用 StructuredOutputParser 生成提示词并 parse。可以看到它根据格式生成了很大一段提示词,并且解析也是正确的。

Tool Call 方式:可靠性更高

有同学说,tool 可以指定参数的对象格式,能不能直接用 tool 来获取结构化的结果呢?当然可以。创建 src/tool-call-args.mjs

js
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { z } from 'zod';

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  temperature: 0,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

// 定义结构化输出的 schema
const scientistSchema = z.object({
  name: z.string().describe("科学家的全名"),
  birth_year: z.number().describe("出生年份"),
  nationality: z.string().describe("国籍"),
  fields: z.array(z.string()).describe("研究领域列表"),
});

const modelWithTool = model.bindTools([
  {
    name: "extract_scientist_info",
    description: "提取和结构化科学家的详细信息",
    schema: scientistSchema
  }
]);

// 调用模型
const response = await modelWithTool.invoke("介绍一下爱因斯坦");
console.log('response.tool_calls:', response.tool_calls);

// 获取结构化结果
const result = response.tool_calls[0].args;
console.log("结构化结果:", JSON.stringify(result, null, 2));
console.log(`\n姓名: ${result.name}`);
console.log(`出生年份: ${result.birth_year}`);
console.log(`国籍: ${result.nationality}`);
console.log(`研究领域: ${result.fields.join(', ')}`);

这里没定义 tool 的实现逻辑,因为我们只是告诉大模型有这个 tool、参数是什么格式,不需要执行。跑一下可以看到,通过返回的 tool_calls 信息,也能拿到结构化的数据。

而且,这种方式比 output parser 更好:因为模型训练的时候就保证了生成 tool calls 的参数一定是符合格式要求的,如果不符合,会重新生成。

那岂不是没必要用 output parser 了?确实,如果只是要求结构化返回数据,用 tool 就行了。

withStructuredOutput:自动选择

现在获取结构化数据一般会用 withStructuredOutput 这个 API:它会判断模型是否支持 tool calls,支持的话就用 tool 的方式获取结构化数据,否则用 output parser 的方式,不用我们自己处理。

js
// 使用 withStructuredOutput 方法
const structuredModel = model.withStructuredOutput(scientistSchema);

// 调用模型
const result = await structuredModel.invoke("介绍一下爱因斯坦");
console.log("结构化结果:", JSON.stringify(result, null, 2));

所以说,现在获取结构化数据更简单了。

那 output parser 一般用不到了?也不是——流式打印返回数据的场景,还是需要 output parser;而且还有一些非 json 格式的,比如 XML、YAML 等格式的内容,也要用 output parser。

流式场景

普通流式

创建 src/stream-normal.mjs

js
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  temperature: 0,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

const prompt = `详细介绍莫扎特的信息。`;
console.log("🌊 普通流式输出演示(无结构化)\n");

const stream = await model.stream(prompt);
let fullContent = '';
let chunkCount = 0;
console.log("📡 接收流式数据:\n");

for await (const chunk of stream) {
  chunkCount++;
  const content = chunk.content;
  fullContent += content;
  process.stdout.write(content); // 实时显示流式文本
}
console.log(`\n\n✅ 共接收 ${chunkCount} 个数据块\n`);
console.log(`📝 完整内容长度: ${fullContent.length} 字符`);

invoke 换成 stream 方法就可以了,用 for await 打印异步返回的 chunk。

withStructuredOutput 流式:不是真流式

先用 withStructuredOutput 做流式结构化输出:

js
const structuredModel = model.withStructuredOutput(schema);
const stream = await structuredModel.stream(prompt);

for await (const chunk of stream) {
  chunkCount++;
  result = chunk;
  console.log(`[Chunk ${chunkCount}]`);
  console.log(JSON.stringify(chunk, null, 2));
}

跑一下可以看到:虽然我们用的是 stream 的流式方式打印的,但是用了 withStructuredOutput 之后,它会在 json 生成完通过校验后再返回(底层是 tool calls)——所以只有一个 chunk 包含完整 json。这样明显不是真的流式。

OutputParser 流式:真流式

创建 src/stream-structured-partial.mjs

js
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { StructuredOutputParser } from '@langchain/core/output_parsers';
import { z } from 'zod';

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  temperature: 0,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

// 使用 zod 定义结构化输出格式
const schema = z.object({
  name: z.string().describe("姓名"),
  birth_year: z.number().describe("出生年份"),
  death_year: z.number().describe("去世年份"),
  nationality: z.string().describe("国籍"),
  occupation: z.string().describe("职业"),
  famous_works: z.array(z.string()).describe("著名作品列表"),
  biography: z.string().describe("简短传记")
});

const parser = StructuredOutputParser.fromZodSchema(schema);
const prompt = `详细介绍莫扎特的信息。\n\n${parser.getFormatInstructions()}`;

console.log("🌊 流式结构化输出演示\n");

const stream = await model.stream(prompt);
let fullContent = '';
let chunkCount = 0;

for await (const chunk of stream) {
  chunkCount++;
  const content = chunk.content;
  fullContent += content;
  process.stdout.write(content); // 实时显示流式文本
}
console.log(`\n\n✅ 共接收 ${chunkCount} 个数据块\n`);

// 解析完整内容为结构化数据
const result = await parser.parse(fullContent);
console.log("📊 解析后的结构化结果:\n");
console.log(JSON.stringify(result, null, 2));

我们用 StructuredOutputParser 解析结果,过程做了流式打印。跑一下可以看到:现在是边生成边打印,最后再 parse。所以流式的情况下,用 output parser 还是更适合的。

Tool Calls 流式:tool_call_chunks

那如果我们就是想用 tool calls 来做结构化输出,但还是想要流式的打印,怎么办呢?其实流式输出的情况下,如果你用了 tool call,是这样返回的:tool_call_chunks 里保存了 tool 参数的部分内容,我们可以用这个来实现流式打印效果:

js
const modelWithTool = model.bindTools([
  {
    name: "extract_scientist_info",
    description: "提取和结构化科学家的详细信息",
    schema: scientistSchema
  }
]);

const stream = await modelWithTool.stream("详细介绍牛顿的生平和成就");

for await (const chunk of stream) {
  // 直接打印每个 chunk 的 tool_calls 信息
  if (chunk.tool_call_chunks && chunk.tool_call_chunks.length > 0) {
    process.stdout.write(chunk.tool_call_chunks[0].args);
  }
}

打印 tool_call_chunks 片段,就可以实现流式打印效果。但是这时候是不能调用 tool 的,因为参数还不完整,没有 tool_calls 信息。

如果我想参数不完整的时候,也能拿到 tool_call 参数的 json 呢?这种就可以用 JsonOutputToolsParser 了:它的作用就是解析 tool_call_chunks 中的内容,拼接成符合 json 格式规范的对象,就算 chunk 还没传输完的时候,也能拿到 json 对象

js
import { JsonOutputToolsParser } from '@langchain/core/output_parsers/openai_tools';

// 1. 绑定工具并挂载解析器
const parser = new JsonOutputToolsParser();
const chain = modelWithTool.pipe(parser);

// 2. 开启流
const stream = await chain.stream("详细介绍牛顿的生平和成就");

for await (const chunk of stream) {
  if (chunk.length > 0) {
    const toolCall = chunk[0];
    console.log(toolCall.args);
  }
}

JsonOutputToolsParser 会尝试解析 tool_call_chunks 生成完整的 tool_calls 信息。跑一下可以看到:就算是流式返回的 tool_call_chunks 还不完整,也会拼成正确格式的 tool_calls——这样你可以实时调用工具,传入部分参数了。

XML 等非 JSON 格式

创建 src/xml-output-parser.mjs

js
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { XMLOutputParser } from '@langchain/core/output_parsers';

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  temperature: 0,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

const parser = new XMLOutputParser();

const question = `请提取以下文本中的人物信息:阿尔伯特·爱因斯坦出生于 1879 年,是一位伟大的物理学家。
${parser.getFormatInstructions()}`;

const response = await model.invoke(question);
console.log("📤 模型原始响应:\n");
console.log(response.content);

const result = await parser.parse(response.content);
console.log("\n✅ XMLOutputParser 自动解析的结果:\n");
console.log(result);

可以看到提示词里加入了一些格式信息,返回的也是 xml 格式,并且正确 parse 了出来。这种也用了 withStructuredOutput(也就是 tool call)来做结构化,还是得用 output parser。

常见问题

withStructuredOutput 支持流式吗? 默认行为下,jsonSchema 模式(非 gpt-3*/gpt-4 系列默认选择)会被官方阻塞流式输出。可以手动指定 method: "functionCalling" 实现流式。注意是否支持流式也和模型有关(比如 glm 就不支持)。

报错 'messages' must contain the word 'json' 部分国内 API(如阿里云百炼)要求 messages 中必须包含"json"字样才能用 response_formatjson_object 模式。在 prompt 里加上用 JSON 返回的说明即可。

千问会忽略 schema 自己决定 JSON 格式?withStructuredOutput + json_object 模式时通义千问可能忽略 schema 结构。方案 1:改用 bindTools;方案 2:在 prompt 里严格约束 JSON 结构。

tool 没被调用为什么能拿到 args? 大模型只是返回 tool call 的参数,具体调用是 agent 来做。绑定 20 个工具也不会对 20 个都返回参数——模型只返回它认为当前需要的那个。

JsonOutputToolsParser 增量打印顺序乱? 流式的增量数据并不是末尾的数据,可能是中间的字段,所以用 currentContent.slice(lastContent.length) 输出不连续,直接打印整个 toolCall.args 就好。

API 太多了记不住? API 知道有啥是干啥的就行,跑一遍就可以了,记不住也没啥。

总结

我们经常需要对大模型输出做一些结构化的限制,这时候就需要 output parser 的 API:

  • output parser 的原理:在提示词里加入格式信息,然后对结果做一下 parse。比如 JsonOutputParserStructuredOutputParserXMLOutputParser
  • tool call 的方式:也完全可以实现结构化限制,而且可靠性更高——模型训练的时候就是保证的
  • 所以,如果是做结构化,直接用 withStructuredOutput 这个 API 就行,它底层就是根据模型来决定是用 tool call 还是 output parser
  • 但它有两个不适合的场景:
    • 流式打印:这种需要用 output parser(边生成边打印,最后 parse)
    • XML 等非 json 格式:也需要 output parser
  • 此外,如果流式打印 tool 参数的过程中,想实时拿到 tool_calls 的 json 对象来调用 tool,可以用 JsonOutputToolsParser 这个 output parser

综上,如果你需要对大模型的输出做结构化,就可以考虑 withStructuredOutput 和 output parser 这两者二选一了。

基于 VitePress 构建 · 专注前端与 AI 实战